Rich Text API
field.richText 富文本 API
通过 FormDialogBuilder 添加的 RichText 富文本字段,可以在脚本中通过字段节点的 richText 属性读写富文本内容。
// 通过富文本的 text 字段名获取字段节点,再读取 richText
const richText = ctx.getFormContext().form.getField('contentTextStr').richText;
richText 帮你处理了富文本 3 个数据字段的一致性:
- 读取:每次调用都基于 json 字段的最新内容计算,用户在编辑器中刚输入的内容也能立刻读到。
- 写入:一次调用会同时更新 text / json / html 3 个字段,并触发一次 text 字段的 fieldOnChanged 事件。
- 失败不落地:内容不合法或目标找不到时,3 个字段都保持原值,并抛出带错误码的异常。
- 返回值是副本:所有方法返回的对象都是深拷贝,修改它们不会改变表单内容。
注意
- 只有富文本的 text 字段 才有
richText,普通字段以及 json / html 数据字段上richText为undefined。建议使用可选链?.访问。 - 字段节点的
value始终是纯文本,请不要通过给 json / html 字段的value赋值来修改富文本。 - 图片 API 只引用已有图片(完整的 http/https 地址或平台上传后得到的 S3 key),不会上传文件;删除图片也只是从内容中移除引用,不会删除文件本身。
API 一览
| 类型 | 名称 | 返回类型 | 说明 |
|---|---|---|---|
| 方法 | get() | RichTextSnapshot | 读取当前内容快照 |
| 方法 | set(content) | RichTextSnapshot | 用新内容替换全文 |
| 方法 | append(content) | RichTextSnapshot | 在末尾追加内容 |
| 方法 | prepend(content) | RichTextSnapshot | 在开头插入内容 |
| 方法 | appendImage(image) | RichTextSnapshot | 在末尾追加一张图片 |
| 方法 | clear() | RichTextSnapshot | 清空内容 |
| 方法 | text(text, options) | TextTarget | 按文字定位,用于修改、格式化、删除某段文字 |
| 方法 | block(index) | BlockTarget | 按段落(块)定位 |
| 方法 | image(locator) | ImageTarget | 按序号、地址或 S3 key 定位图片 |
| 方法 | edit(recipe) | RichTextSnapshot | 批量修改,所有操作只提交一次 |
| 属性 | raw readonly | RawApi | 高级入口,直接操作 Tiptap JSON 节点 |
内容格式
RichTextSnapshot 内容快照
所有读写方法都返回最新的内容快照。
| 名称 | 类型 | 说明 |
|---|---|---|
| document | object | 富文本 JSON(Tiptap 格式),根节点 type 为 doc |
| text | string | 纯文本,和 text 字段的值一致 |
| html | object | HTML 存储值:{ html: string, imageKeys: object, hasError: boolean, hasUploading: boolean } |
ContentInput 块内容
set、append、prepend 以及块定位的插入/替换方法,接收以下任意一种格式:
| 格式 | 示例 | 说明 |
|---|---|---|
| 字符串 | '第一段\n第二段' | 按换行拆分为多个段落 |
| 段落 | { type: 'paragraph', content: InlineInput } | 一个段落 |
| 标题 | { type: 'heading', level: 2, content: InlineInput } | level 取值 1 ~ 6 |
| 图片 | { type: 'image', image: ImageInput } | 一个只包含图片的段落 |
| 段落、标题、图片组成的数组 | [{ type: 'heading', level: 2, content: '标题' }, { type: 'paragraph', content: '正文' }] | 多个块;数组元素必须是块对象,不能是字符串 |
InlineInput 行内内容
段落、标题的 content,以及文字定位的插入/替换方法,接收以下任意一种格式:
| 格式 | 示例 |
|---|---|
| 字符串 | '普通文字' |
| 带样式的文字 | { text: '重点', bold: true, color: '#d4380d' } |
| 以上格式组成的数组 | ['合同金额:', { text: '100 万元', bold: true }] |
带样式的文字支持以下属性:
| 名称 | 类型 | 说明 |
|---|---|---|
| text | string | 文字内容,必填 |
| bold | boolean | 加粗 |
| italic | boolean | 斜体 |
| underline | boolean | 下划线 |
| strike | boolean | 删除线 |
| color | string | 文字颜色 |
| link | string | 超链接地址 |
ImageInput 图片
url 和 s3Key 二选一。
| 名称 | 类型 | 说明 |
|---|---|---|
| url | string | 完整的 http/https 图片地址 |
| s3Key | string | 平台上传完成后得到的 S3 key |
| alt | string | 替代文字 |
| title | string | 图片标题 |
| width | number | 宽度 |
| height | number | 高度 |
| align | 'left' | 'center' | 'right' | 对齐方式 |
读取内容
get() 读取快照
返回当前内容的 RichTextSnapshot。
const richText = ctx.getFormContext().form.getField('contentTextStr').richText;
const snapshot = richText.get();
console.log(snapshot.text); // 纯文本
console.log(snapshot.document); // 富文本 JSON
console.log(snapshot.html.html); // HTML 字符串
// 判断是否为空
if (!snapshot.text.trim()) {
qiqi.ui.message.showWarning('内容不能为空');
}
整体写入
set(content) 替换全文
用 ContentInput 替换全部内容。
// 纯文本,按换行拆分为两个段落
richText.set('第一段内容\n第二段内容');
// 标题 + 段落
richText.set([
{ type: 'heading', level: 2, content: '项目概况' },
{ type: 'paragraph', content: ['项目编号:', { text: 'P-2026-001', bold: true }] },
{ type: 'paragraph', content: '项目负责人:张三' },
]);
append(content) / prepend(content) 追加内容
在末尾追加 / 在开头插入 ContentInput。如果当前内容为空,会直接替换掉空段落,不会留下空行。
// 末尾追加一段带链接的文字
richText.append({
type: 'paragraph',
content: ['详情请见:', { text: '项目主页', link: 'https://www.example.com/project/1' }],
});
// 开头插入一段红色加粗的提示
richText.prepend({
type: 'paragraph',
content: { text: '【草稿】', bold: true, color: '#cf1322' },
});
clear() 清空内容
richText.clear();
按文字定位
text(text, options) 文字定位
按文字查找内容中的某一处,返回 TextTarget,再对这段文字做修改。
定位对象只记住查找条件,每次调用方法时都会在最新内容中重新查找,所以可以先保存定位对象,再在之后使用。
options 参数
| 名称 | 类型 | 说明 | 默认值 |
|---|---|---|---|
| occurrence | number | 'first' | 'last' | 有多处匹配时选第几处,数字从 0 开始 | 'first' |
| caseSensitive | boolean | 是否区分大小写 | true |
| block | number | 'first' | 'last' | 只在指定段落(块)中查找,数字从 0 开始 | - |
TextTarget 文字定位对象
| 名称 | 返回类型 | 说明 |
|---|---|---|
| get() | object | undefined | 返回匹配位置信息(text、occurrence 等),找不到返回 undefined |
| exists() | boolean | 是否找到 |
| count() | number | 匹配的总数(忽略 occurrence) |
| insertBefore(content) | RichTextSnapshot | 在文字前插入 InlineInput |
| insertAfter(content) | RichTextSnapshot | 在文字后插入 InlineInput |
| insertImageBefore(image) | RichTextSnapshot | 在文字前插入图片 |
| insertImageAfter(image) | RichTextSnapshot | 在文字后插入图片 |
| replaceWith(content) | RichTextSnapshot | 用 InlineInput 替换这段文字 |
| format(style) | RichTextSnapshot | 设置样式,只修改声明的样式;color、link 传 null 表示移除 |
| remove() | RichTextSnapshot | 删除这段文字 |
| block() | BlockTarget | 定位到这段文字所在的段落 |
| imageBefore() | ImageTarget | 定位到紧挨在文字前面的图片 |
| imageAfter() | ImageTarget | 定位到紧挨在文字后面的图片 |
注意
get、exists、count 找不到时返回 undefined、false、0;修改类方法找不到时会抛出 TEXT_NOT_FOUND 错误。不确定文字是否存在时,请先用 exists() 判断。
语法
const richText = ctx.getFormContext().form.getField('contentTextStr').richText;
// 把"待定"替换成实际日期,并加粗
richText.text('待定').replaceWith({ text: '2026-10-20', bold: true });
// 把所有"风险"加粗标红:先统计数量,再逐个处理
const total = richText.text('风险').count();
for (let i = 0; i < total; i++) {
richText.text('风险', { occurrence: i }).format({ bold: true, color: '#cf1322' });
}
// 去掉之前设置的颜色和链接
richText.text('项目主页').format({ color: null, link: null });
// 只在第 2 个段落中查找,并在后面补充文字
richText.text('负责人', { block: 1 }).insertAfter('(已确认)');
// 不区分大小写,删除最后一处
richText.text('todo', { caseSensitive: false, occurrence: 'last' }).remove();
// 先判断再修改,避免抛错
const target = richText.text('附:签名');
if (target.exists()) {
target.block().remove(); // 删除整段
}
按段落定位
block(index) 段落定位
按段落(块)定位,index 为数字(从 0 开始)、'first' 或 'last',返回 BlockTarget。
BlockTarget 段落定位对象
| 名称 | 返回类型 | 说明 |
|---|---|---|
| get() | object | undefined | 返回段落信息(index、node),找不到返回 undefined |
| exists() | boolean | 段落是否存在 |
| insertBefore(content) | RichTextSnapshot | 在段落前插入 ContentInput |
| insertAfter(content) | RichTextSnapshot | 在段落后插入 ContentInput |
| insertImageBefore(image) | RichTextSnapshot | 在段落前插入图片 |
| insertImageAfter(image) | RichTextSnapshot | 在段落后插入图片 |
| replaceWith(content) | RichTextSnapshot | 用 ContentInput 替换该段落 |
| remove() | RichTextSnapshot | 删除该段落;删除最后一个段落时会保留一个空段落 |
| image(index) | ImageTarget | 定位该段落中的第几张图片,数字从 0 开始 |
找不到段落时,修改类方法会抛出 BLOCK_NOT_FOUND 错误。
语法
// 替换第一个段落为标题
richText.block('first').replaceWith({ type: 'heading', level: 1, content: '月度经营分析' });
// 在第 2 个段落后插入两段内容(顶层字符串按换行拆成多个段落)
richText.block(1).insertAfter('补充说明一\n补充说明二');
// 删除最后一个段落
if (richText.block('last').exists()) {
richText.block('last').remove();
}
// 在"结论"所在段落之前插入一个小标题
richText.text('结论').block().insertBefore({ type: 'heading', level: 3, content: '分析结论' });
图片
appendImage(image) / image(locator) 图片
appendImage(image):在末尾追加一个只包含图片的段落,参数为 ImageInput。image(locator):定位一张图片,返回 ImageTarget。locator可以是:- 数字(全文第几张,从 0 开始)、
'first'、'last'; { url, occurrence?, block? }:按图片地址定位;{ s3Key, occurrence?, block? }:按 S3 key 定位。
- 数字(全文第几张,从 0 开始)、
ImageTarget 图片定位对象
| 名称 | 返回类型 | 说明 |
|---|---|---|
| get() | object | undefined | 返回图片信息(index、source、node),找不到返回 undefined |
| exists() | boolean | 图片是否存在 |
| update(input) | RichTextSnapshot | 修改图片属性,只修改声明的属性;属性传 null 表示清除 |
| replace(image) | RichTextSnapshot | 用新的 ImageInput 替换图片 |
| remove() | RichTextSnapshot | 从内容中移除图片(不删除文件) |
找不到图片时,修改类方法会抛出 IMAGE_NOT_FOUND 错误。
语法
// 追加一张网络图片,居中显示
richText.appendImage({
url: 'https://www.example.com/images/chart.png',
alt: '销售趋势图',
width: 480,
align: 'center',
});
// 追加一张已上传到平台的图片
richText.appendImage({ s3Key: 'tenant/rich-text/diagram.png', alt: '流程图' });
// 在"如下图所示:"之后插入图片
richText.text('如下图所示:').insertImageAfter({ url: 'https://www.example.com/images/flow.png' });
// 修改第一张图片的宽度并清除标题
richText.image('first').update({ width: 320, title: null });
// 把指定地址的图片替换为新图片
richText
.image({ url: 'https://www.example.com/images/old.png' })
.replace({ url: 'https://www.example.com/images/new.png', align: 'center' });
// 删除"签字:"后面紧挨着的签名图片
const signImage = richText.text('签字:').imageAfter();
if (signImage.exists()) {
signImage.remove();
}
批量修改
edit(recipe) 批量修改
edit 把多次修改合并为一次提交:在回调中调用的方法只会被记录,回调结束后按顺序执行,全部成功才写入表单,并且只触发一次 fieldOnChanged。任何一步失败,整批修改都不会生效。
回调参数 draft 提供和 richText 相同的写入方法:set、append、prepend、appendImage、clear、text(...)、block(...)、image(...),但没有 get、exists、count 等读取方法。
注意
回调必须是同步函数。传入 async 函数或返回 Promise 时会抛出 ASYNC_RECIPE_NOT_SUPPORTED 错误,表单内容不变。需要异步获取数据时,请先 await 拿到数据,再调用 edit。
语法
const customerName = ctx.getFormContext().form.getField('customerName').value;
richText.edit((draft) => {
draft.clear();
draft.append({ type: 'heading', level: 2, content: '拜访记录' });
draft.append({ type: 'paragraph', content: ['客户:', { text: customerName || '-', bold: true }] });
draft.append('拜访结论:待补充');
// 后面的操作会基于前面操作后的内容执行
draft.text('待补充').format({ color: '#8c8c8c', italic: true });
});
高级:raw
raw 用于直接操作富文本 JSON 节点,适合需要精确控制结构的场景。节点路径 path 是由 content 数组下标组成的数组:[] 表示根节点,[0] 表示第一个段落,[0, 1] 表示第一个段落中的第二个子节点。
| 名称 | 返回类型 | 说明 |
|---|---|---|
| getSnapshot() | RichTextSnapshot | 同 get() |
| read(path) | object | undefined | 读取指定路径的节点,路径不存在返回 undefined |
| insert(input) | RichTextSnapshot | 插入节点:{ parentPath?, index?, nodes },默认插入到根节点末尾 |
| insertImage(input) | RichTextSnapshot | 插入图片:{ source: { type: 'url' \| 's3Key', value }, parentPath?, index?, ... } |
| update(path, node) | RichTextSnapshot | 整体替换指定路径的节点 |
| remove(path) | RichTextSnapshot | 删除指定路径的节点 |
| replace(document) | RichTextSnapshot | 用完整的 doc 替换全文 |
| clear() | RichTextSnapshot | 清空内容 |
| apply(operations) | RichTextSnapshot | 按顺序执行一组操作,全部成功才提交一次 |
语法
const raw = richText.raw;
// 读取第一个段落节点
const firstBlock = raw.read([0]);
// 在根节点第 1 个位置插入一个分割线和一个段落
raw.insert({
index: 1,
nodes: [
{ type: 'horizontalRule' },
{ type: 'paragraph', content: [{ type: 'text', text: '以下为附件说明' }] },
],
});
// 一次执行多个操作;失败时可以通过 error.operationIndex 知道是第几个操作出错
raw.apply([
{ type: 'insertContent', position: 'append', content: '第一段' },
{ type: 'insertContent', position: 'append', content: '第二段' },
{ type: 'formatText', text: { text: '第二段' }, style: { bold: true } },
]);
// 用一份完整的 JSON 替换全文
raw.replace({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: '全新的内容' }] },
],
});
注意
raw 写入的节点必须符合富文本编辑器支持的结构,否则会抛出 INVALID_DOCUMENT 或 UNSUPPORTED_NODE 错误。日常使用优先选择 set、append、text()、block() 等方法。
错误处理
富文本 API 的异常都带有稳定的错误码 code,请根据 code 做判断,不要依赖错误文案。
| 属性 | 类型 | 说明 |
|---|---|---|
| code | string | 错误码,见下表 |
| message | string | 错误描述 |
| operationIndex | number | 批量操作中失败的是第几个(从 0 开始) |
| path | number[] | 与错误相关的节点路径 |
| 错误码 | 说明 |
|---|---|
| TEXT_NOT_FOUND | 文字定位找不到 |
| BLOCK_NOT_FOUND | 段落定位找不到 |
| IMAGE_NOT_FOUND | 图片定位找不到 |
| NODE_PATH_NOT_FOUND | raw 中的节点路径不存在 |
| ASYNC_RECIPE_NOT_SUPPORTED | edit 传入了异步回调 |
| INVALID_DOCUMENT | 内容结构不合法 |
| UNSUPPORTED_NODE | 使用了编辑器不支持的节点 |
| INVALID_IMAGE_SOURCE | 图片地址不是完整的 http/https 地址,或 S3 key 为空 |
| INVALID_IMAGE_PARENT | 图片插入的位置不合法 |
| SERIALIZE_FAILED | 生成纯文本或 HTML 失败 |
| COMMIT_FAILED | 写入表单字段失败,3 个字段已恢复为原值 |
try {
richText.text('不存在的文字').remove();
} catch (e) {
if (e.code === 'TEXT_NOT_FOUND') {
qiqi.ui.message.showWarning('没有找到要删除的文字');
} else {
throw e;
}
}
完整示例
下面的示例创建一个"周报"对话框:
- 打开时写入周报模板;
- 勾选"附加签名"时在末尾追加签名,取消勾选时删除签名段落;
- 修改"项目名称"时同步替换正文中的项目名;
- 提交时检查"待补充"占位文字是否都已填写,并读取结果。
const formDialogBuilder = qiqi.builder.createDialogBuilder();
const baseFieldSet = formDialogBuilder.addFieldSet({ title: '基本信息', fieldsPerLine: 2 });
baseFieldSet.addField({ id: 'projectName', title: '项目名称', type: 'Text' });
baseFieldSet.addField({ id: 'withSignature', title: '附加签名', type: 'Checkbox' });
const contentFieldSet = formDialogBuilder.addFieldSet({ title: '周报内容', fieldsPerLine: 1 });
contentFieldSet.addField({
id: 'content',
title: '周报内容',
type: 'RichText',
fieldNames: { text: 'contentTextStr', json: 'contentTextJson', html: 'contentTextHtml' },
});
contentFieldSet.addField({ id: 'contentTextStr', title: '周报内容', type: 'Text', hidden: true });
contentFieldSet.addField({ id: 'contentTextJson', title: '周报内容', type: 'JsonObject', hidden: true });
contentFieldSet.addField({ id: 'contentTextHtml', title: '周报内容', type: 'JsonObject', hidden: true });
const SIGNATURE_TEXT = '—— 本周报由系统生成';
// 记录上一次的项目名,用于替换正文
let lastProjectName = '【项目名称】';
function getRichText(ctx) {
return ctx.getFormContext().form.getField('contentTextStr').richText;
}
formDialogBuilder.open({
title: '填写周报',
size: 'large',
formOnLoad: (ctx) => {
getRichText(ctx).set([
{ type: 'heading', level: 2, content: ['项目:', { text: lastProjectName, bold: true }] },
{ type: 'heading', level: 3, content: '本周完成' },
{ type: 'paragraph', content: { text: '待补充', color: '#8c8c8c' } },
{ type: 'heading', level: 3, content: '下周计划' },
{ type: 'paragraph', content: { text: '待补充', color: '#8c8c8c' } },
{ type: 'heading', level: 3, content: '风险与问题' },
{ type: 'paragraph', content: '暂无' },
]);
},
fieldOnChanged: (ctx) => {
const { fieldName, currentField } = ctx.getCurrentContext();
const richText = getRichText(ctx);
// 写入富文本也会触发 contentTextStr 的 fieldOnChanged,只处理关心的字段,避免循环
if (fieldName === 'withSignature') {
const signature = richText.text(SIGNATURE_TEXT);
if (currentField.value && !signature.exists()) {
richText.append({ type: 'paragraph', content: { text: SIGNATURE_TEXT, italic: true } });
} else if (!currentField.value && signature.exists()) {
signature.block().remove();
}
return;
}
if (fieldName === 'projectName') {
const newName = currentField.value || '【项目名称】';
const target = richText.text(lastProjectName);
if (target.exists()) {
target.replaceWith({ text: newName, bold: true });
}
lastProjectName = newName;
}
},
dialogOnConfirm: async (ctx) => {
const richText = getRichText(ctx);
// 模板中的"待补充"还没有被替换,说明周报没有填写完整
if (richText.text('待补充').exists()) {
qiqi.ui.message.showError('请完善周报中"待补充"的内容');
ctx.getEventArgs().cancel();
return;
}
const snapshot = richText.get();
console.log('纯文本:', snapshot.text);
console.log('HTML:', snapshot.html.html);
const value = ctx.getFormContext().form.value;
// value.contentTextStr 纯文本
// value.contentTextJson 富文本 JSON
// value.contentTextHtml HTML 存储值
console.log(value);
},
});